모던 리액트 11장
모던 리액트 공부 기록
2024-02-13
App 디렉토리
13버전 이전까지 Next에서 페이지 공통으로 쓰이는 헤더나 푸터를 같이 넣을 수 있는 곳은 _document나 _app이 유일했다. 그리고 이 두 파일은 서로 다른 목적을 갖고 있었다. 12버전까지는 무언가 페이지 공통 레이아웃을 유지할 수 있는 방법은 _app이 유일해다.
- _document : 페이지에서 쓰이는 html이나 body태그를 수정하거나 서버 사이드 렌더링 시 CSS-in_JS르 지원하기 위한 코드를 삽입하는 제한적은 용도로 사용된다. 오직 서버에서만 동작한다.
_ app : 페이지를 초기화하기 위한 파일이고, globalCSS 주입, 전역 에러 핸들링, 페이지 변경 시 강태 유지 등과 같은 역할을 한다.
이러한 한계를 극복하기 위해 app 레이아웃이 등장한다.
라우팅
먼저 눈에 뜨이는 변화는 /pages로 정의하던 라우팅 방식이 /app로 바뀌었다는 점, 파일명으로 라우팅하는 것이 불가능해졌다는 것이다. Next에서 라우팅은 파일 시스템을 기반으로 하고 있으며 Next가 나온 뒤 쭉 유지된 방식이다. pages나 app은 다음과 같은 차이가 있다.
-
12버전 이하 : pages/a/b.tsx이나 pages/a/b/index.tsx는 모두 동일한 주소다.
-
13버전 : app/a/b는 a/b/로 변환되며 파일명은 무시된다. 폴더명까지 주소로서 유효하다.
13버전부터는 app 디렉토리 내부의 폴더명이 라우팅이 되며 파일명은 몇가지로 제한되어 있다. 그중 하나가 layout이다. 이 파일은 페이지의 기본적인 레이아웃 요소를 구성하는 부분이다. 해당 폴더에 layout이 있으면 하위 폴더,주소에 모두 영향을 미친다.
/app/layout.tsx
export default function RootLayout({children} : {children:React.ReactNode}) {
<html lang = "ko">
<body>
<main>{children}</main>
</body>
</html>
}
먼저 루트에는 단 하나의 layout을 만들 수 있다. 이 layout은 모든 페이지에 공통적으로 영향을 미치는 파일이다. 보통 head,html태그 내부에서 사용되는 공통 요소들을 다룬다. _document가 없어지면서 루트의 레이아웃에서 CSS-IN-JS를 넣어준다.
'use client';
import { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
export default function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode;
}) {
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
useServerInsertedHTML(() => {
const styles = styledComponentsStyleSheet.getStyleElement();
styledComponentsStyleSheet.instance.clearTag();
return <>{styles}</>;
});
if (typeof window !== 'undefined') return <>{children}</>;
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
);
}
layout파일에서 주의해야 할 점은 다음과 같다.
- layout은 무조건 app 디렉토리에서만 쓸 수 있다. layout.js | ts | tsx | jsx로 사용해야 하며 레이아웃 이외의 용도로는 쓸 수 없다.
- layout은 children을 받아서 렌더링 해야 한다. 레이아웃이므로 당연히 그려야 할 컴포넌트를 외부에서 주입하고 그려야 한다.
- layout 내부에는 반드시 export default로 내보내는 컴포넌트가 있어야 한다.
- layout 내부에서도 비동기 요청을 처리할 수 있다.
layout과 마찬가지로 page도 예약어이며 이전까지 Next에서 일반적으로 다뤘던 페이지를 의미한다.
export default function BlogPage() {
return <>블로그 글</>
}
이 page는 앞에서 구성된 layout을 기반으로 리액트 컴포넌트를 노출하기 된다. 요기서는 다음과 같은 props를 받을 수 있다.
- params : 옵셔널 값으로 [...id]와 같은 동적 라우트 파라미터를 사용할 경우 해당 파라미터에 값이 들어간다.
- searchParams : ?a=1&b=21로 접근할 경우 {a:1,b:21}이라는 자바스크립트 객체 값이 들어오게 된다. searchParams에 의존적인 작업은 반드시 page 내부에서만 수행해야 한다.
page도 마찬가지로 다음과 같은 규칙이 있다.
- page도 app 디렉토리 내부의 예약어이다.레이아웃 이외의 목적으로 사용할 수 없다.
- page도 반드시 export default로 내보내는 컴포넌트가 있어야 한다.
error.js은 해당 라우팅 영역에서 사용되는 공통 에러 컴포넌트이다. 이것을 사용하면 특정 라우팅별 다른 UI를 렌더링하는 것이 가능해진다.
'use client' // Error components must be Client Components
import { useEffect } from 'react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])
return (
<div>
<h2>Something went wrong!</h2>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try again
</button>
</div>
)
}
error 페이지는 에러 정보를 담고 있는 error 객체와 에러 바운더리를 초기화할 reset을 props로 받는다. 이 에러바운더리는 클라이언트에서만 적용된다.
error.js는 중첩된 자식 세그먼트 또는 page.js 컴포넌트를 감싸는 React Error Boundary를 자동으로 생성한다. error.js 파일에서 내보낸 React 컴포넌트가 폴백 컴포넌트로 사용된다. Error Boundary 내에서 에러가 발생하면 에러가 포함되고 fallback 컴포넌트가 렌더링된다. fallback error 컴포넌트가 활성화되면 Error Boundary 위의 레이아웃은 해당 상태를 유지하고 대화형 상태를 유지하며 Error 컴포넌트는 오류를 복구하는 기능을 표시할 수 있다.
루트 app/error.js boundary는 루트 app/layout.js 또는 app/template.js 컴포넌트에서 발생한 오류를 포착하지 못한다.
이러한 루트 컴포넌트의 에러를 구체적으로 처리하려면 루트 앱 디렉터리에 있는 app/global-error.js라는 error.js를 사용한다.
루트 error.js와 달리 global-error.js Error boundary는 전체 애플리케이션을 감싸며, 해당 fallback 컴포넌트가 활성화되면 루트 레이아웃을 대체합니다. 따라서 global-error.js는 자체 및 태그를 정의해야 한다
global-error.js는 가장 세분화된 에러 UI이며 전체 애플리케이션에 대한 "포괄적인" 에러처리로 간주할 수 있다. 루트 컴포넌트는 일반적으로 덜 동적이며 다른 error.js boundary가 대부분의 에러를 포착하므로 자주 트리거되지 않을 가능성이 높다.
global-error.js가 정의되어 있더라도 전역적으로 공유되는 UI 및 브랜딩을 포함하는 루트 레이아웃 내에서 렌더링될 fallback 컴포넌트가 있는 루트 error.js를 정의하는 것이 좋다.
// app/global-error.tsx
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
);
}
데이터를 불러오는 중일때나 서버 컴포넌트 내부에서 에러가 발생하면 Next.js는 결과인 Error 객체를 error prop으로 가장 가까운 error.js 파일로 전달한다. 다음 개발을 실행할 때 에러는 직렬화되어 서버 컴포넌트에서 클라이언트 error.js로 전달한다.
app 디렉토리가 출시되면서 pages/api와 동일하게 app/api를 기준으로 디렉토리 라우팅을 지원하며 /api 내부에서도 파일명 라우팅이 없어졌다. 그 대신 디렉토리가 라우팅 주소를 담당하며 파일명은 route.js로 통일되었다.
/app/api/hello/route.ts
import {NextRequest} from 'next/server'
export async function GET(req:Request) {
}
export async function POST(req:Request) {
}
export async function PUT(req:Request) {
}
export async function PATCH(req:Request) {
}
export async function DELETE(req:Request) {
}
이 route.ts 내부의 REST API의 GET,POST와 같은 메서드를 예약어로 선언하면 HTTP요청에 맞게 해당 메서드를 호출하는 방식으로 동작한다.이 route함수들은 다음의 props를 받는다.
- request : api요청과 관련된 cookie,headers 뿐만 아니라 nextURL등의 주소 등 요청에 들어온 정보를 볼 수 있다.
- context : params를 갖는 객체이며 동적 라우팅 파라미터 객체가 들어 있다.
서버 컴포넌트
리액트 18에서 도입된 서버 컴포넌트는 서버 사이드 렌더링과 전혀 다른 개념이다.
먼저 서버 사이드 렌더링은 응답받은 페이지 전체를 HTML로 렌더링하는 과정을 서버에서 수행한 후 클라이언트로 내려준다. 그리고 이후 클라이언트에서 하이드레이션 과정을 거쳐 서버의 결과물을 확인하고 이벤트를 붙이는 등의 작업을 수행한다.
서버 사이드 렌더링은 초기 인터렉션은 불가능하나 정적인 HTML을 빠르게 내려주는 데 초점을 두고 있다. 초기 정적 HTML을 받고 클라이언트에서 번들을 다운하고 실행하는데 비용이 든다.
웹 사이트를 방문하면 리액트 실행에 필요한 패키지를 다운받고 컴포넌트 트리를 만들고 DOM에 렌더링한다. 서버 사이드 렌더링을 할 때에는 서버에서 DOM을 만들고 클라이언트에서 Hydrate를 걸쳐 이벤트를 DOM에 추가하기도 하고, 상태를 추적할 수도 있다.
이러한 구조는 크게 다음의 문제가 존재할 수 있다.
- 번들 크기가 0인 컴포넌트를 만들 수 없다. 만약 외부에서 설치한 패키지를 쓸 때 해당 패키지 크기가 크다면, 해당 패키지를 사용자 환경에 의존해 다운받고 실행까지 거쳐야 한다.
- 백엔드 리소스에 대한 직접적인 접근이 불가능하다.
- 자동 코드 분할이 불가능하다. 일반적으로 리액트에서는 lazy를 이용해 자동 코드 분할을 구현해왔다. React.lazy를 이용해 수동 분할할 수 있지만, 개발자가 일일이 이를 기억해야 한다.
const RouterA = lazy(() => import('./RouterA.ts'))
const RouterB = lazy(() => import('./RouterB.ts'))
const RouterC = (props) => {
}
이런 배경으로 인해 서버 컴포넌트가 등장한다. 서버 컴포넌트는 하나의 언어 , 하나의 프레임워크, 하나의 API와 개념을 사용하면서 서버와 클라이언트 모두에서 컴포넌트를 렌더링하는 기법을 말한다.
서버에서 할 수 있는 일은 서버가 처리하고 서버가 할 수 없는 나머지 작업은 클라이언트인 브라우저에서 수행한다. 즉 일부 컴포넌트는 클라이언트에서, 일부 컴포넌트는 서버에서 렌더링되는 것이다. 여기서 클라이언트 컴포넌트는 서버 컴포넌트를 import로 가져올 수 없다. 그 반대는 가능하다.
서버 컴포넌트
- 요청이 오면 그 순간 서버에서 한번 실행되므로 상태를 가질 수 없다. useState과 같은 훅을 사용할 수 없다.
- 렌더링 생명주기를 사용할 수 없다.
- effect나 state에 의존하는 훅을 사용할 수 없다.
- window,document에 접근할 수 없다.
- 데이터베이스 파일 시스템 등 서버에 있는 데이터를 async , await로 접근할 수 있다.
- 다른 서버컴포넌트나, 클라이언트 컴포넌트를 렌더링할 수 있다.
리액트는 모든 컴포넌트를 다 서버에서 실행 가능한 것으로 판단한다. 대신 클라이언트 컴포넌트라는 것을 명시적으로 적으려면 'use client'를 적어주면 된다.
'use client'
import OtherClientComponent from './OtherClientComponent'
function ClientComponent(){
const [state,setState] = useState(false)
return <OterhClientComponent onClick = {() => setState(true)} />
}
서버 컴포넌트는 어떻게 동작하는지?
- 서버가 렌더링 요청을 받는다. 서버가 렌더링 과정을 수행해야 하므로 리액트 서버 컴포넌트를 사용하는 모든 페이지는 항상 서버에서 시작된다.
- 서버는 받은 요청에 따라 컴포넌트를 JSON으로 직렬화한다. 서버에서 렌더링하는 것은 직렬화 해 내보내고 클라이언트 컴포넌트는 해당 공간을 잠시 비워둔다(플레이스 홀더로 대체한다.) 이후 브라우저가 결과물을 받아 다시 렌더링을 수행한다.
M1:{"id":"./src/SearchField.client.js","chunks":["client5"],"name":""}
M2:{"id":"./src/EditButton.client.js","chunks":["client1"],"name":""}
S3:"react.suspense"
J0:["$","div",null,{"className":"main","children":[["$","section",null,{"className":"col sidebar","children":[["$","section",null,{"className":"sidebar-header","children":[["$","img",null,{"className":"logo","src":"logo.svg","width":"22px","height":"20px","alt":"","role":"presentation"}],["$","strong",null,{"children":"React Notes"}]]}],["$","section",null,{"className":"sidebar-menu","role":"menubar","children":[["$","@1",null,{}],["$","@2",null,{"noteId":null,"children":"New"}]]}],["$","nav",null,{"children":["$","$3",null,{"fallback":["$","div",null,{"children":["$","ul",null,{"className":"notes-list skeleton-container","children":[["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}],["$","li",null,{"className":"v-stack","children":["$","div",null,{"className":"sidebar-note-list-item skeleton","style":{"height":"5em"}}]}]]}]}],"children":"@4"}]
- M으로 시작하는 줄은 클라이언트 컴포넌트를 의미하고, 클라이언트 번들에서 해당 함수를 렌더링하기 위해 필요한 정보가 어디있는지 나타낸다.
- S는 리액트의 서스펜스를 의미한다.
- J는 서버에서 렌더링된 서버 컴포넌트이다. 렌더링에 필요한 모든 element,props,children이 들어가 있다.
@2,@4와 같은 @로 시작하는 부분이 있는데 이 정보는 나중에 렌더링으 완료되었을 때 들어가야 할 컴포넌트를 의미하는 것으로 (일종의 플레이스홀더) @1은 M!이 렌더링되면 저 @1자리에 @M1이 들어가야 한다는 것을 의미한다.
- 브라우저가 리액트 컴포넌트 트리를 구성한다. 브라우저는 서버로부터 스트리밍으로 JSON의 결과물을 받고 해당 결과물을 바탕으로 트리를 구성해 컴포넌트를 만든다. M1과 같은 클라이언트 컴포넌트는 클라이언트에서 렌더링하고 서버에서 만들어진 결과물을 받았다면 이 정보를 기반으로 트리를 그린다.
서버 컴포넌트의 특징은 그래서 크게 다음과 같다.
서버-> 클라이언트로 정보를 보낼 때 스트리밍형식으로 보내고 클라이언트가 줄 단위로 JSON을 읽고 컴포넌트를 렌더링 할 수 있다. 서버 사이드 렌더링과는 다르게 JSON형태로 결과물이 보내진다. 단순히 HTML을 그리는 게 아니라 서버 - 클라이언트 컴포넌트의 혼합을 위한 것이다.
Next에서 리액트 서버 컴포넌트
Next에서 서버 사이드 렌더링과 정적 페이지 제공을 위해 사용되던 getServerSideProps, getStaticProps등이 app 디렉토리에서는 제거되었다. 대신 모든 요청은 fetch를 기반으로 이루어진다.
async function getData() {
const res = await fetch('...')
if(!res.ok){
//가까운 에러 바운더리로 전달
throw new Error('error')
}
return res.json()
}
export async function Page(){
const data = await getData()
return (
<main>
<Children data = {data} />
</main>
)
}
이 fetch는 기본적으로 동일한 요청은 캐싱해둔다.
fetch(`https://...`, { cache: 'force-cache' | 'no-store' })
'force-cache' (기본값) - Next.js는 데이터 캐시에서 일치하는 요청을 찾아봅니다. 일치하는 요청이 있고 신선하다면, 캐시에서 반환됩니다. 일치하는 요청이 없거나 오래된 요청인 경우, Next.js는 원격 서버에서 리소스를 가져와 다운로드한 리소스로 캐시를 업데이트합니다.
'no-store' - Next.js는 캐시를 확인하지 않고 매 요청마다 원격 서버에서 리소스를 가져옵니다. 그리고 다운로드한 리소스로 캐시를 업데이트하지 않습니다.
next 13에서는 정적인 라우팅에 대해 빌드 타임에 렌더링을 해두고 캐싱을 해놓아서 재사용할 수 있게 해놓았고, 동적인 라우팅에 대해서는 서버에 매번 요청이 올 때마다 컴포넌트를 렌더링할 수 있게 변경했다.
//app/page.tsx
async function fetchData(){
const res = await fetch('...')
const data = await res.json()
return data
}
export default async function Page(){
const data = await fetchData()
return (
<ul>
{data.map((item,key)) => <li key = {key}>{item}</li>}
</ul>
)
}
해당 주소를 캐싱하지 않는 방법도 있다. 미리 빌드해 해당 요청을 대기시키지 않고 요청이 올때마다 fetch 요청 이후 렌더링을 수행한다. 만약 next에서 제공하는 headers나 cookie와 같은 함수를 쓰게 되면 해당 함수는 동적인 연산을 바탕으로 결과를 반환하는 것으로 인식해 정적 렌더링 대상에서 제거된다.
async function fetchData(){
const res = await fetch('...',{
cache:'no-store'
//revalidate : 0도 동일
})
const data = await res.json()
return data
}
export default async function Page(){
const data = await fetchData()
return (
<ul>
{data.map((item,key)) => <li key = {key}>{item}</li>}
</ul>
)
}
동적인 주소지만 특정 주소에 대해 캐싱을 하려면 generateStaticParams을 사용하면 된다.
fetch 옵션에 따른 작동 방식을 정리하면 다음과 같다.
- cache : force-cache : 기본적으로 getStaticProps와 유사하게 데이터를 캐싱해 해당 데이터를 관리한다.
- cache : no-store : 캐싱하지 않고 매번 새로운 데이터를 불러온다.
- cache : {next: {revalidate : 10 }} : 정해진 기간동안 캐싱하고 그 이후에는 캐싱을 파기한다.
만약 이렇게 revalidate를 정해준다면 하위에 있는 모든 라우팅은 페이지를 revalidate 시간 간격으로 갱신해 렌더링한다.
과거 서버사이드렌더링 방식은 요청받은 페이지를 모두 렌더링 해 내릴 때까지 사용자가 아무것도 볼 수 없고 빈 페이지만 보게 된다. 그리고 이 페이지는 Hydrate전까지 사용자가 인터렉션 할 수 없는 정적인 페이지이다. 이를 해결하기 위해 페이지가 다 완성될 때까지 기다리지 않고 HTML을 작은 단위로 쪼개 완성하는 대로 클라이언트로 내보내는 스트리밍이 도입되었다.
이 스트리밍을 활용할 수 있는 방법은 크게 2가지이다.
- 경로에 loading.tsx을 배치한다. 아래 코드는 다음과 같은 구조를 갖는다.
<Layout>
<Header></Header>
<NavBar></NavBar>
//loading 파일이 fallback으로 들어간다.
<Suspense fallback = {<Loading />}>
<Page />
</Suspense>
</Layout>
- 직접적으로 Suspense를 사용한다.
Loading이 Suspense를 기반으로 만들어진 Next의 규칙이기 때문에 직접 Suspense를 사용하는 것도 동일한 효과를 낼 수 있따.